SPDX-FileCopyrightText: 2026 Sarah Fanning & Joshua Neville SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be
SPDX-License-Identifier: GPL-3.0-or-later
import random
import bmesh
import bpyCLEAR SCENE #
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)SETTINGS #
x_lines = 11
y_lines = 11
line_width = 0.200
line_height = 0.200RANDOM SETTINGS # RANDOM_SEED locks the random sequence for reproducibility Set to None for a new result on every run RANDOM_HEIGHT_MIN / MAX controls the height range for outer pillar randomisation #
RANDOM_SEED = 21
RANDOM_HEIGHT_MIN = 2.5
RANDOM_HEIGHT_MAX = 4.5
if RANDOM_SEED is not None:
random.seed(RANDOM_SEED)SPACING MAPS # X-axis: raw random values normalised so total X span equals total Y span Preserves the random character of the snare while keeping the grid square randomness contained by structure — the snare lives inside the time signature #
raw = [random.uniform(0.5, 1.5) for _ in range(x_lines - 1)]
total = sum(raw)
target = (y_lines - 1) * 1.0 # = 10.0, same as y total
x_spacing = [round(v * target / total, 4) for v in raw]
y_spacing = [1.0] * (y_lines - 1) # stays uniform
T = line_width / 2THICKNESS MAPS # 0.500 marks the 5x5 core zone - thicker pillars make the core visually distinct T is the default thin pillar used everywhere outside the core Lower and upper maps are identical in zone layout but serve independent halves #
lower_thickness = [
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
]
upper_thickness = [
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
[T, T, T, T, T, T, T, T, T, T, T],
]FIXED CORE PILLAR VALUES # The 5x5 core zone is fully hand-composed — no randomisation Two-tier structure within the core: inner 3x3: fixed at 5 — the stable, unmovable centre outer ring: diagonal gradients climbing toward the corners Lower half: gradient climbs from top-left to bottom-right Upper half: gradient climbs from bottom-left to top-right — the same material, inverted The two cores face each other without touching, same logic irreconcilable in space
_core_lower = {
(3, 3): 5,
(3, 4): 4.75,
(3, 5): 4.50,
(3, 6): 4.25,
(3, 7): 4,
(4, 3): 4.75,
(4, 4): 5,
(4, 5): 5,
(4, 6): 5,
(4, 7): 4.25,
(5, 3): 4.50,
(5, 4): 5,
(5, 5): 5,
(5, 6): 5,
(5, 7): 4.50,
(6, 3): 4.25,
(6, 4): 5,
(6, 5): 5,
(6, 6): 5,
(6, 7): 4.75,
(7, 3): 4,
(7, 4): 4.25,
(7, 5): 4.50,
(7, 6): 4.75,
(7, 7): 5,
}
_core_upper = {
(3, 3): 4,
(3, 4): 4.25,
(3, 5): 4.50,
(3, 6): 4.75,
(3, 7): 5,
(4, 3): 4.25,
(4, 4): 5,
(4, 5): 5,
(4, 6): 5,
(4, 7): 4.75,
(5, 3): 4.50,
(5, 4): 5,
(5, 5): 5,
(5, 6): 5,
(5, 7): 4.50,
(6, 3): 4.75,
(6, 4): 5,
(6, 5): 5,
(6, 6): 5,
(6, 7): 4.25,
(7, 3): 5,
(7, 4): 4.75,
(7, 5): 4.50,
(7, 6): 4.25,
(7, 7): 4,
}RANDOMISED HEIGHT MAP BUILDER # rnd() draws a random outer pillar height within RANDOM_HEIGHT_MIN / MAX #
def rnd():
return round(random.uniform(RANDOM_HEIGHT_MIN, RANDOM_HEIGHT_MAX), 4)def build_height_map(thickness_map, core_values):
row_h_left = [rnd() for _ in range(y_lines)]
row_h_right = [rnd() for _ in range(y_lines)]
height_map = []
for y_idx in range(y_lines):
h_left = row_h_left[y_idx]
h_right = row_h_right[y_idx]
row = []
for x_idx in range(x_lines):
if thickness_map[y_idx][x_idx] == 0.500:
row.append(core_values[(y_idx, x_idx)])
else:
frac = x_idx / (x_lines - 1)
h = h_left + (h_right - h_left) * frac
row.append(round(h, 4))
height_map.append(row)
return height_map
lower_heights = build_height_map(lower_thickness, _core_lower)
upper_heights = build_height_map(upper_thickness, _core_upper)CURVE CONTROLS # 24 interpolated steps per segment — working resolution for the Catmull-Rom surfaces #
CURVE_STEPS = 24VALIDATION # n lines need n-1 gaps — catches spacing list mismatches before any geometry is built
assert len(x_spacing) == x_lines - 1
assert len(y_spacing) == y_lines - 1BUILD POSITIONS # Converts spacing gaps into absolute world coordinates Accumulates each gap from 0.0 then recentres so the structure sits at the world origin #
def build_positions(spacing_list):
positions = [0.0]
for gap in spacing_list:
positions.append(positions[-1] + gap)
offset = positions[-1] / 2.0
return [p - offset for p in positions]
x_positions = build_positions(x_spacing)
y_positions = build_positions(y_spacing)
x_total = x_positions[-1] - x_positions[0]
y_total = y_positions[-1] - y_positions[0]
flip_base = y_total
ceiling = flip_base * 1HELPERS #
def make_bar(location, scale):
bpy.ops.mesh.primitive_cube_add(location=location)
obj = bpy.context.object
obj.scale = scale
return objdef get_lower(y_idx, x_idx):
try:
return lower_heights[y_idx][x_idx]
except IndexError:
return 1.0def get_upper(y_idx, x_idx):
try:
return upper_heights[y_idx][x_idx]
except IndexError:
return 1.0def get_lower_thickness(y_idx, x_idx):
try:
return lower_thickness[y_idx][x_idx]
except IndexError:
return Tdef get_upper_thickness(y_idx, x_idx):
try:
return upper_thickness[y_idx][x_idx]
except IndexError:
return Tdef catmull_rom(p0, p1, p2, p3, t):
return 0.5 * (
2 * p1
+ (-p0 + p2) * t
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t
)Y-AXIS # Reusable function that builds a single smooth Y-axis surface panel z_top_fn and z_bot_fn are passed in so the same function serves both halves: lower half: z_top = h, z_bot = 0.0 upper half: z_top = ceiling, z_bot = ceiling - h hy_ext / yp_ext extend with phantom points so the spline doesn’t kink at the boundaries top and bottom vertices paired into quad faces to build each surface strip #
def build_curved_surface(name, x_pos, height_fn, z_top_fn, z_bot_fn):
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
bm = bmesh.new()
top_verts, bot_verts = [], []
hy = [height_fn(y_idx) for y_idx in range(y_lines)]
yp = y_positions
hy_ext = [hy[0]] + hy + [hy[-1]]
yp_ext = [yp[0] - (yp[1] - yp[0])] + list(yp) + [yp[-1] + (yp[-1] - yp[-2])]
for seg in range(y_lines - 1):
i = seg + 1
p0h, p1h, p2h, p3h = hy_ext[i - 1], hy_ext[i], hy_ext[i + 1], hy_ext[i + 2]
p0y, p1y, p2y, p3y = yp_ext[i - 1], yp_ext[i], yp_ext[i + 1], yp_ext[i + 2]
steps = CURVE_STEPS + (1 if seg == y_lines - 2 else 0)
for step in range(steps):
t = step / CURVE_STEPS
h = catmull_rom(p0h, p1h, p2h, p3h, t)
y_pt = catmull_rom(p0y, p1y, p2y, p3y, t)
top_verts.append(bm.verts.new((x_pos, y_pt, z_top_fn(h))))
bot_verts.append(bm.verts.new((x_pos, y_pt, z_bot_fn(h))))
bm.verts.ensure_lookup_table()
for i in range(len(top_verts) - 1):
try:
bm.faces.new(
[top_verts[i], top_verts[i + 1], bot_verts[i + 1], bot_verts[i]]
)
except:
pass
bm.to_mesh(mesh)
bm.free()X-AXIS # Builds a flat vertical mesh panel connecting pillar tops across one row Interpolates height linearly between the first and last pillar using world-space X positions matches the same ramp logic used in build_height_map — pillar and surface stay aligned #
def build_straight_x_surface(name, y_pos, y_idx, height_fn, z_top_fn, z_bot_fn):
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
bm = bmesh.new()
top_verts, bot_verts = [], []
h_first = height_fn(y_idx, 0)
h_last = height_fn(y_idx, x_lines - 1)
x_first = x_positions[0]
x_last = x_positions[-1]
x_span = x_last - x_first
for x_idx, x_pos in enumerate(x_positions):
frac = (x_pos - x_first) / x_span
h = h_first + (h_last - h_first) * frac
top_verts.append(bm.verts.new((x_pos, y_pos, z_top_fn(h))))
bot_verts.append(bm.verts.new((x_pos, y_pos, z_bot_fn(h))))
bm.verts.ensure_lookup_table()
for i in range(x_lines - 1):
try:
bm.faces.new(
[top_verts[i], top_verts[i + 1], bot_verts[i + 1], bot_verts[i]]
)
except:
pass
bm.to_mesh(mesh)
bm.free()LOWER HALF #
Horizontal rows - flat bars along X at each Y position, spanning full grid width Kick drum pulse: uniform, metronomic, the anchor of the structure
Vertical columns - flat bars along Y at each X position, spanning full grid depth Snare pulse: irregular spacing, forward momentum against the kick’s anchor
Intersection pillars - vertical bar at every grid crossing Height from get_lower, thickness from get_lower_thickness location z = h/2 so the base sits flush on the ground plane
Straight x-row surfaces # z_top = h, z_bot = 0.0 - surface rises from the floor tracing the ramp profile
Curved y-column surfaces # z_top = h, z_bot = 0.0 The wave rises from the floor - Moroder’s layering logic made geometric #
for y_idx, y_pos in enumerate(y_positions):
make_bar(
location=(0, y_pos, line_height / 2),
scale=((x_total / 2) + line_width / 2, line_width / 2, line_height / 2),
)
for x_idx, x_pos in enumerate(x_positions):
make_bar(
location=(x_pos, 0, line_height / 2),
scale=(line_width / 3, (y_total / 2) + line_width / 2, line_height / 2),
)
for x_idx, x_pos in enumerate(x_positions):
for y_idx, y_pos in enumerate(y_positions):
h = get_lower(y_idx, x_idx)
t = get_lower_thickness(y_idx, x_idx)
make_bar(location=(x_pos, y_pos, h / 2), scale=(t, t, h / 2))Straight x-row surfaces #
for y_idx, y_pos in enumerate(y_positions):
build_straight_x_surface(
name=f"surface_x_row_{y_idx}",
y_pos=y_pos,
y_idx=y_idx,
height_fn=get_lower,
z_top_fn=lambda h: h,
z_bot_fn=lambda h: 0.0,
)Curved y-column surfaces #
for x_idx, x_pos in enumerate(x_positions):
build_curved_surface(
name=f"surface_y_col_{x_idx}",
x_pos=x_pos,
height_fn=lambda yi, _xi=x_idx: get_lower(yi, _xi),
z_top_fn=lambda h: h,
z_bot_fn=lambda h: 0.0,
)UPPER HALF # The lower half mirrored and inverted to hang from the ceiling Independent height maps, same structural logic - same material, different voice The two halves face each other across the interior without touching: identical in structure, irreconcilable in space
Horizontal rows at ceiling - positioned at ceiling - line_height/2 so bars sit flush against the ceiling rather than the floor
Vertical columns at ceiling - same as lower columns, mirrored upward
Flipped pillars - location z = ceiling - h/2 so pillars hang downward from the ceiling
Straight x-row surfaces — flipped # z_top = ceiling, z_bot = ceiling - h Mirror of the lower x-row surfaces, descending rather than ascending
Curved y-column surfaces — flipped # z_top = ceiling, z_bot = ceiling - h The wave descends from above — the same surface logic, inverted
for y_idx, y_pos in enumerate(y_positions):
make_bar(
location=(0, y_pos, ceiling - line_height / 2),
scale=((x_total / 2) + line_width / 2, line_width / 2, line_height / 2),
)
for x_idx, x_pos in enumerate(x_positions):
make_bar(
location=(x_pos, 0, ceiling - line_height / 2),
scale=(line_width / 2, (y_total / 2) + line_width / 2, line_height / 2),
)
for x_idx, x_pos in enumerate(x_positions):
for y_idx, y_pos in enumerate(y_positions):
h = get_upper(y_idx, x_idx)
t = get_upper_thickness(y_idx, x_idx)
make_bar(location=(x_pos, y_pos, ceiling - h / 2), scale=(t, t, h / 2))Straight x-row surfaces #
for y_idx, y_pos in enumerate(y_positions):
build_straight_x_surface(
name=f"flip_surface_x_row_{y_idx}",
y_pos=y_pos,
y_idx=y_idx,
height_fn=get_upper,
z_top_fn=lambda h: ceiling,
z_bot_fn=lambda h: ceiling - h,
)Curved y-column surfaces #
for x_idx, x_pos in enumerate(x_positions):
build_curved_surface(
name=f"flip_surface_y_col_{x_idx}",
x_pos=x_pos,
height_fn=lambda yi, _xi=x_idx: get_upper(yi, _xi),
z_top_fn=lambda h: ceiling,
z_bot_fn=lambda h: ceiling - h,
)The final Cube! #
Y-axis: uniform spacing (1.0 throughout): the kick drum, metronomic and absolute, the pulse doesn’t deviate # X-axis: randomised: the snare, unpredictable internally but always resolving to the same width # The wave is Moroder’s core provocation made physical: tension without harmony # No chord changes, no resolution, only accumulation. Each Y-column layer stacks onto the same pulse, smooth and inevitable # The pillars are call and response. The synthesizer voices answering the sequencer’s ground # Outer pillars: algorithmically derived, interpolated from edge values, servants of the ramp, the sequencer setting the terms # Core pillars: independently generated from a separate height range, taller, occupying the centre, the synthesizer claiming its own voice # The tension between the calculated outer field and the autonomous core is the same tension Moroder engineered between the mechanical and the expressive # The snare creates forward momentum against the kick’s anchor, irregular, never quite where you expect it. Fixed spacing would be a grid, not a score # The normalisation back to a fixed total span is the critical move: randomness contained by structure - the snare lives inside the time signature, it doesn’t break it # The core sits inside the ramp defined by the outer pillars - calculated independently it would pierce or sink below the mesh, breaking the logic # The core has directionality - not a flat plateau but a landscape with internal momentum, the same way Chase is not static repetition but repetition that is always going somewhere #